Skip to content

fix: separate maintenance from processOne (#122)#134

Merged
coji merged 3 commits intomainfrom
fix/processone-purge-separation
Mar 16, 2026
Merged

fix: separate maintenance from processOne (#122)#134
coji merged 3 commits intomainfrom
fix/processone-purge-separation

Conversation

@coji
Copy link
Owner

@coji coji commented Mar 16, 2026

Summary

  • Simplify processOne() to pure claim → execute → return, removing releaseExpiredLeases and fire-and-forget auto-purge that broke the "false = idle" contract
  • Move maintenance (lease normalization + auto-purge) to runIdleMaintenance, called via worker onIdle callback and processUntilIdle (only when actually idle, not when maxRuns hit)
  • Ensure stop() awaits in-flight idle maintenance and errors in maintenance can't hang the worker loop

Test plan

  • pnpm validate — 28/28 tasks pass (format, lint, typecheck, 297 tests)
  • pnpm --filter @coji/durably test:node:postgres — 11/11 pass
  • Auto-purge test updated: uses processUntilIdle (awaited) instead of processOne + setTimeout hack
  • Verified processOne returns false with zero background work
  • Verified purge runs only on idle cycles

Closes #122

🤖 Generated with Claude Code

Summary by CodeRabbit

リリースノート

  • 新機能

    • アイドル時に任意で呼び出せるメンテナンス機能を公開しました(期限切れリース解放・古い完了タスクの削除)。
  • 改善

    • アイドルと稼働フローを見直し、アイドル期間中にメンテナンスが確実に実行されるよう最適化しました。
    • クレーム直後の即時自動削除を廃止し、メンテナンスに一元化しました。
  • テスト

    • アイドル時のメンテナンス挙動と停止時の待機動作を検証するテストを追加しました。

processOne was mixing claim/execute with maintenance (releaseExpiredLeases,
auto-purge). The fire-and-forget purge broke the "false = idle" contract.

- Simplify processOne to pure claim → execute → return true/false
- Move releaseExpiredLeases and auto-purge into runIdleMaintenance
- Add onIdle callback to worker, called when processOne returns false
- processUntilIdle runs maintenance only when actually idle (not maxRuns)
- stop() awaits in-flight idle maintenance via unified cycle promise
- Wrap runIdleMaintenance in try/catch to prevent worker loop hangs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
durably-demo Ready Ready Preview Mar 16, 2026 1:19pm
durably-demo-vercel-turso Ready Ready Preview Mar 16, 2026 1:19pm

@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

Warning

Rate limit exceeded

@coji has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 18 minutes and 18 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 02be1de8-b4af-4e42-886e-1f41874e964b

📥 Commits

Reviewing files that changed from the base of the PR and between 84b1da3 and fd43900.

📒 Files selected for processing (2)
  • packages/durably/src/worker.ts
  • packages/durably/tests/shared/purge.shared.ts
📝 Walkthrough

Walkthrough

アイドル時に保守タスクを分離して導入。runIdleMaintenanceをDurablyStateに追加し、期限切れリース解放と古いターミナルランの削除をアイドル周期で実行するように変更。クレームパスの即時自動削除を削除し、workerにonIdleフックを追加。

Changes

コホート / ファイル(s) 概要
アイドル保守(コア)
packages/durably/src/durably.ts
DurablyStaterunIdleMaintenance: () => Promise<void>を追加。lastPurgeAtを削除。createDurably内でrunIdleMaintenanceを実装・stateへ注入し、アイドル時に期限切れリース解放と古いターミナルラン削除(retainRunsMsに基づく)を実行するようプロセスを調整。クレームパスからの即時自動パージを削除。エラーはworker:errorイベントで通知。
ワーカー(ポーリング)
packages/durably/src/worker.ts
createWorkeronIdleオプションを追加。processOneがアイドルを返した際、ポーリング内でonIdle()を非同期的に呼び出すサイクルを導入。inFlight管理をサイクルPromiseへ置換し、停止時は進行中のアイドル保守も待機。
テスト更新
packages/durably/tests/shared/purge.shared.ts, packages/durably/tests/shared/worker.shared.ts
パージ関連テストをprocessOneprocessUntilIdleに変更してアイドル保守を確実に発火するよう調整。新規テストでバックログが残る状態では保守が走らないこと(maxRunsヒット時)を検証。stop()がアイドル保守の完了を待つ挙動を検証するテストを追加。

Sequence Diagram

sequenceDiagram
    participant App as アプリケーション
    participant Worker as Worker
    participant Durably as Durably
    participant State as State\n(runIdleMaintenance)
    participant Store as ストレージ

    App->>Worker: pollRuns開始
    loop ポーリングサイクル
        Worker->>Durably: processOne()
        Durably->>Store: claimNextPendingRun / クレーム試行
        alt ランあり
            Durably-->>Worker: true (処理実行)
            Durably->>Store: ラン処理・更新
        else ランなし(アイドル)
            Durably-->>Worker: false (アイドル)
            alt onIdleが設定されている
                Worker->>State: onIdle() を呼び出す
                State->>State: runIdleMaintenance 実行
                State->>Store: 期限切れリース解放
                State->>Store: 古いターミナルラン削除(retainRunsMs適用)
                State-->>Worker: 保守完了
            end
        end
    end
    Worker-->>App: 停止/完了
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 アイドルのときに そっと掃除をする
期限切れは去り 新しい道が見える
ワーカー休めば ひと息メンテナンス
走り続けるための 小さな祈りよ ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PRタイトルは、processOneから保守メカニズムを分離するという主要な変更を明確かつ簡潔に説明しており、変更セットの中心的な目的と完全に関連しています。
Linked Issues check ✅ Passed プルリクエストは#122の全ての要件を満たしており、processOneから火災と忘れる削除を削除し、runIdleMaintenanceに分離し、アイドル中のみに保守を実行し、エラーハンドリングを追加しています。
Out of Scope Changes check ✅ Passed すべての変更は#122の目的に関連しており、processOneの簡潔化、runIdleMaintenanceの導入、ワーカーのonIdleコールバック、stop()がメンテナンスを待つロジックなど、リンク済み課題の要件に直接対応しています。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/processone-purge-separation
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/durably/src/durably.ts (1)

1045-1046: lastPurgeAt がローカル変数に変更された点について

lastPurgeAtDurablyState から削除され、createDurably のローカル変数になりました。これは実装詳細を隠蔽する良い変更ですが、テストコメント(purge.shared.ts の 247-248 行、261-263 行)で lastPurgeAt への言及が残っています。テストコメントは内部状態を説明するためのものなので問題ありませんが、将来のメンテナ向けにコメントが内部実装を参照していることを認識しておいてください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/src/durably.ts` around lines 1045 - 1046, Tests still
mention the internal variable lastPurgeAt which was removed from DurablyState
and made a local variable inside createDurably; update the test comments in
purge.shared.ts (around the existing references) to stop referring to
lastPurgeAt directly — either remove the internal-variable mention or replace it
with a comment that the value is now held internally within createDurably
(DurablyState -> createDurably, lastPurgeAt) so future readers aren’t misled by
implementation details.
packages/durably/src/worker.ts (1)

36-48: onIdle() での例外がポーリングループをクラッシュさせる可能性

onIdle() が例外をスローした場合、poll() 内で捕捉されず、ポーリングループが停止してしまいます。durably.ts 側の runIdleMaintenance は try/catch でラップされていますが、将来的に異なる onIdle コールバックを渡す可能性を考慮すると、ここで防御的に処理することを検討してください。

🛡️ 防御的エラーハンドリングの提案
     const cycle = (async () => {
       const didProcess = await processOne({ workerId: activeWorkerId })
       if (!didProcess && onIdle && running) {
-        await onIdle()
+        try {
+          await onIdle()
+        } catch {
+          // onIdle errors are non-fatal; allow polling to continue
+        }
       }
     })()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/src/worker.ts` around lines 36 - 48, The polling cycle
currently awaits onIdle() without handling exceptions, so any error thrown by
the onIdle callback can crash the poll loop; inside the anonymous async cycle
(the variable cycle) that calls processOne({ workerId: activeWorkerId }) and
then awaits onIdle(), wrap the onIdle() invocation in a try/catch (or otherwise
guard the await) so exceptions are caught and handled (e.g., log via the same
logger or swallow) and do not escape the cycle; keep the existing inFlight
assignment and finally block behavior unchanged so inFlight is still cleared on
completion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/durably/src/durably.ts`:
- Around line 1045-1046: Tests still mention the internal variable lastPurgeAt
which was removed from DurablyState and made a local variable inside
createDurably; update the test comments in purge.shared.ts (around the existing
references) to stop referring to lastPurgeAt directly — either remove the
internal-variable mention or replace it with a comment that the value is now
held internally within createDurably (DurablyState -> createDurably,
lastPurgeAt) so future readers aren’t misled by implementation details.

In `@packages/durably/src/worker.ts`:
- Around line 36-48: The polling cycle currently awaits onIdle() without
handling exceptions, so any error thrown by the onIdle callback can crash the
poll loop; inside the anonymous async cycle (the variable cycle) that calls
processOne({ workerId: activeWorkerId }) and then awaits onIdle(), wrap the
onIdle() invocation in a try/catch (or otherwise guard the await) so exceptions
are caught and handled (e.g., log via the same logger or swallow) and do not
escape the cycle; keep the existing inFlight assignment and finally block
behavior unchanged so inFlight is still cleared on completion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 594dcd07-6de1-41c8-ab4e-cbc5f55d7f15

📥 Commits

Reviewing files that changed from the base of the PR and between 7d09841 and f7fb8d1.

📒 Files selected for processing (3)
  • packages/durably/src/durably.ts
  • packages/durably/src/worker.ts
  • packages/durably/tests/shared/purge.shared.ts

- Verify processUntilIdle({ maxRuns }) does NOT run maintenance when
  backlog remains (only runs when actually idle)
- Verify stop() awaits in-flight idle maintenance before resolving

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add defensive try/catch for onIdle() in worker poll loop to prevent
  crashes from unexpected callback errors
- Remove internal variable name references from test comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
packages/durably/tests/shared/purge.shared.ts (1)

271-309: テストロジックは正しいが、d.stop()の呼び出しが欠落している可能性あり。

テストロジック自体は正確です:processUntilIdlemaxRunsに達した場合はreachedIdleがfalseのままでメンテナンスを実行せず、真にアイドル状態になった時のみメンテナンスが実行されることを検証しています。

ただし、Line 308でd.db.destroy()のみ呼び出し、d.stop()を呼び出していません。このテストではd.start()を呼び出していないためワーカーポーリングは起動していませんが、PRの目的によるとstop()は「進行中のアイドルメンテナンスを待機する」ようになっています。processUntilIdle()は内部でメンテナンスを待機するため問題ない可能性がありますが、一貫性のためにd.stop()を追加することを検討してください。

♻️ 一貫性のための修正案
        // Now drain fully (reaches idle) — maintenance runs and purges
        await d.processUntilIdle()
        expect(await d.getRun(run1.id)).toBeNull()

+       await d.stop()
        await d.db.destroy()
      })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/tests/shared/purge.shared.ts` around lines 271 - 309, The
test omits shutting down the Durably instance: add an explicit await d.stop()
before calling d.db.destroy() so the worker is cleanly stopped and any
in-flight/idle maintenance is awaited; reference the Durably instance methods
processUntilIdle, stop, and the existing d.db.destroy() call and insert the
await d.stop() immediately prior to d.db.destroy().
packages/durably/tests/shared/worker.shared.ts (2)

94-96: 未使用のイベントリスナー

run:leased イベントリスナーが登録されていますが、このテストではジョブがトリガーされないため、このイベントは発火しません。コメントに「worker to process something」とありますが、実際には何も処理されません。

このリスナーは削除するか、テストの意図に沿ったイベント(例: worker:error でメンテナンスエラーを監視)に変更することを検討してください。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/tests/shared/worker.shared.ts` around lines 94 - 96, The
test registers an unused event listener d.on('run:leased') which never fires;
remove this noop listener or replace it with a meaningful one that matches the
test intent (for example d.on('worker:error') to assert maintenance or error
conditions). Locate the registration of d.on('run:leased') in the test (symbol:
d.on) and either delete that line or change the event name and accompanying
assertion to watch for the appropriate event (e.g., 'worker:error') so the test
actually observes and asserts on emitted events.

81-110: テストアサーションが常に成功する(トートロジー)

maintenanceCompleted = trueawait d.stop() の直後に設定し、その後 expect(maintenanceCompleted).toBe(true) でアサートしていますが、これは逐次実行されるため常に成功します。このアサーションはメンテナンスが実際に待機されたことを検証していません。

テストの価値は stop() がハングしないことを確認する点にありますが、アサーションが誤解を招く形になっています。

♻️ より意味のあるテストへの改善案
 it('stop() awaits in-flight idle maintenance before resolving', async () => {
-  let maintenanceCompleted = false
+  let maintenanceStarted = false
+  let stopResolved = false

   // Use retainRuns to ensure runIdleMaintenance does real work
   const d = createDurably({
     dialect: createDialect(),
     pollingIntervalMs: 50,
     retainRuns: '30d',
   })
   await d.migrate()

-  // Listen for the idle-maintenance cycle completing via worker:error
-  // or simply track that stop() doesn't resolve before maintenance
-  d.on('run:leased', () => {
-    // noop — just need the worker to process something
-  })
+  // Track idle maintenance via worker events if available
+  d.on('worker:error', () => {
+    // Maintenance errors would be emitted here
+  })

   d.start()

   // Let the worker go through at least one idle cycle
   // (processOne returns false → onIdle runs releaseExpiredLeases)
   await new Promise((r) => setTimeout(r, 150))
+  maintenanceStarted = true

   // stop() should await any in-flight maintenance
   await d.stop()
-  maintenanceCompleted = true
+  stopResolved = true

-  expect(maintenanceCompleted).toBe(true)
+  // Verify stop() resolved without hanging
+  expect(stopResolved).toBe(true)
+  expect(maintenanceStarted).toBe(true)
   await d.db.destroy()
 })

あるいは、テストの意図がより明確になるようにテスト名とコメントを調整することも検討してください:

it('stop() resolves without hanging when idle maintenance may be in-flight', async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/durably/tests/shared/worker.shared.ts` around lines 81 - 110, The
test currently sets maintenanceCompleted = true immediately after awaiting
d.stop(), which makes the assertion tautological; instead, verify that stop()
awaited an in-flight maintenance by setting the flag from the actual maintenance
completion callback (e.g., inside whatever handler signals idle maintenance
completion such as the worker's maintenance callback or an emitted event like
'worker:error' or a custom 'maintenance:completed' listener) and then await
d.stop() before asserting the flag, or alternatively remove the redundant
flag/assertion and rename the test to something like "stop() resolves without
hanging when idle maintenance may be in-flight" to reflect the intent; update
references to maintenanceCompleted and the test name accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/durably/tests/shared/purge.shared.ts`:
- Around line 271-309: The test omits shutting down the Durably instance: add an
explicit await d.stop() before calling d.db.destroy() so the worker is cleanly
stopped and any in-flight/idle maintenance is awaited; reference the Durably
instance methods processUntilIdle, stop, and the existing d.db.destroy() call
and insert the await d.stop() immediately prior to d.db.destroy().

In `@packages/durably/tests/shared/worker.shared.ts`:
- Around line 94-96: The test registers an unused event listener
d.on('run:leased') which never fires; remove this noop listener or replace it
with a meaningful one that matches the test intent (for example
d.on('worker:error') to assert maintenance or error conditions). Locate the
registration of d.on('run:leased') in the test (symbol: d.on) and either delete
that line or change the event name and accompanying assertion to watch for the
appropriate event (e.g., 'worker:error') so the test actually observes and
asserts on emitted events.
- Around line 81-110: The test currently sets maintenanceCompleted = true
immediately after awaiting d.stop(), which makes the assertion tautological;
instead, verify that stop() awaited an in-flight maintenance by setting the flag
from the actual maintenance completion callback (e.g., inside whatever handler
signals idle maintenance completion such as the worker's maintenance callback or
an emitted event like 'worker:error' or a custom 'maintenance:completed'
listener) and then await d.stop() before asserting the flag, or alternatively
remove the redundant flag/assertion and rename the test to something like
"stop() resolves without hanging when idle maintenance may be in-flight" to
reflect the intent; update references to maintenanceCompleted and the test name
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6b340f34-c3d8-4221-b118-791549ead840

📥 Commits

Reviewing files that changed from the base of the PR and between f7fb8d1 and 84b1da3.

📒 Files selected for processing (2)
  • packages/durably/tests/shared/purge.shared.ts
  • packages/durably/tests/shared/worker.shared.ts

@coji coji merged commit 91f950b into main Mar 16, 2026
5 checks passed
@coji coji deleted the fix/processone-purge-separation branch March 16, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

design: processOne idle contract is broken by fire-and-forget purge

1 participant